Modals are essential UI components in web applications, often used for tasks such as displaying additional information, capturing user input, or confirming actions. However, traditional approaches to managing modals present challenges such as maintaining state, handling navigation, and ensuring that context is preserved on refresh.
With Next.js, intercepting and parallel routes introduce a powerful way to make modals URL-synced and shareable. This enables seamless deep linking, backward navigation to close modals, and forward navigation to reopen them – all without compromising the user experience.
In this article, we’ll walk through the process of building a dynamic feedback modal in Next.js. Along the way, we’ll explore advanced techniques, accessibility best practices, and tips for improving your modals for production-ready applications.
Why shareable modals matter
Modals have become an essential feature of modern web applications. Whether it’s a login form, product preview, or feedback submission, modals allow users to interact with your application without leaving the current page. But as simple as modals may seem, traditional implementations can present significant challenges for both users and developers.
Challenges with traditional modals
1. State management in large applications:
Most modal implementations rely on the client-side state to keep track of whether the modal is open or closed. In small applications, this is manageable using tools like React’s “useState” or the Context API. However, in larger applications with multiple modals, this approach becomes complex and error-prone. For example:
- You may need to manage overlapping modal states across different components.
- Global state management solutions such as Redux or Zustand can help, but add unnecessary complexity for something as simple as opening or closing a modal
2. Refresh behaviour:
Traditional modals lose their state when the page is refreshed. For example:
- A user clicks a “Give Feedback” button, opening a modal.
- They refresh the page, expecting the modal to stay open, but instead, it closes because the client-side state is reset. This disrupts the user experience, forcing users to repeat actions or lose their place in the workflow.
3. Inability to share modal states via URLs:
Consider a scenario where a user wants to share a particular modal with a colleague. With traditional client-side modals, there’s no URL representing the modal state, so the user can’t share or bookmark the modal. This makes the application less versatile and harder to navigate for users who expect modern, shareable interfaces.
How Next.js solves these challenges
Next.js provides a routing system that integrates seamlessly with modals, solving the challenges above. By leveraging features like intercepting routes and parallel routes, you can implement modals that are URL-synced, shareable, and persistent.
1.URL-based state for deep linking:
In Next.js, modal states can be tied directly to URLs. For example:
- Navigating to /feedback can open a feedback form modal.
- This URL can be shared or bookmarked, and refreshing the page will keep the modal open.
This is achieved by associating modal components with specific routes in your file system, giving the modal a dedicated URL.
2.Preserving context and consistent navigation:
Unlike traditional modals, Next.js maintains navigation consistency. For example:
- Pressing the back button closes the modal instead of navigating to the previous page.
- Navigating forward reopens the modal, maintaining the user flow.
These behaviours are automatically handled by Next.js’ routing system, reducing the need for custom logic and improving the user experience.
Next.js functions for creating shareable modals
Intercepting routes
Intercepting routes in Next.js allows you to “intercept” navigation to a specific route and render additional UI, such as a modal, without replacing the current page content. This is done using a special folder naming convention in your file system.
Implementation:
Intercepting route folder:
- To create an interception route, use a folder prefixed with (.).
- For example, if you wanted to intercept navigation to “/feedback” and display it as a modal, you would create the following structure:
- app
├── @modal
├── (.)feedback
│ │ └── page.tsx
│ └── default.tsx
├── feedback
│ └── page.tsx
- app/feedback/page.tsx renders the full-page version of the feedback form.
- app/@modal/(.)feedback/page.tsx renders the modal version.
Route behaviour:
- Navigating directly to /feedback will render the full page (app/feedback/page.tsx).
- Clicking on a “Give Feedback” button navigates to /feedback, but renders the modal (app/@modal/(.)feedback/page.tsx).
Example modal file:
Listing 1:
import { Modal } from '@/components/modal'; export default function FeedbackModal() { return ( <Modal> <h2 className="text-lg font-bold">Give Feedback</h2> <form className="mt-4 flex flex-col gap-4"> <textarea placeholder="Your feedback..." className="border rounded-lg p-2" /> <button type="submit" className="bg-blue-500 text-white py-2 px-4 rounded-lg" > Submit </button> </form> </Modal> ); }
Parallel routes
Parallel routes allow multiple routes to be rendered simultaneously in different “slots” of the UI. This feature is particularly useful for rendering modals without disrupting the main layout.
Implementation:
Create a slot:
- Parallel routes are implemented using folders prefixed with @. For example, @modal defines a slot for modal content.
- In the root layout, you can include the modal slot next to the main page content.
Example layout file:
Listing 2:
// app/layout.tsx import "./globals.css"; export default function RootLayout({ modal, children, }: { modal: React.ReactNode; children: React.ReactNode; }) { return ( <html lang="en"> <body> <div>{modal}</div> <main>{children}</main> </body> </html> ); }
Fallback content:
- Define a default.tsx file in the @modal folder to specify the fallback content when the modal is not active.
Listing 3:
// app/@modal/default.tsx export default function Default() { return null; // No modal by default }
Why these features matter
Intercepting routes in Next.js enable dynamic modal rendering without disrupting the layout of the main application. They allow you to associate specific modal components with their own URLs, making it possible to implement deep linking and sharing for modals. This ensures that users can navigate directly to a specific modal or share its state via a URL.
Parallel routes, on the other hand, separate the rendering logic of modals from the rest of the application. By isolating modal behaviour into its own designated slot, parallel routes simplify development and improve maintainability. This separation ensures that modals can be rendered independently, without interfering with the layout or functionality of other parts of the application.
By combining intercepting and parallel routes, Next.js transforms the way modals are implemented. These features make modals more user-friendly by supporting modern navigation patterns and sharing capabilities, while also enhancing developer efficiency through cleaner, more modular code.
Building a feedback modal in Next.js with TailwindCSS
Step 1: Setting up the /feedback route
The /feedback route serves as the main feedback page. TailwindCSS is used to style the form and layout.
Listing 4:
// app/feedback/page.tsx export default function FeedbackPage() { return ( <main className="flex flex-col items-center justify-center min-h-screen bg-gray-100"> <h1 className="text-2xl font-bold text-gray-800">Feedback</h1> <p className="text-gray-600">We’d love to hear your thoughts!</p> <form className="mt-4 flex flex-col gap-4 w-full max-w-md"> <textarea className="border border-gray-300 rounded-lg p-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Your feedback..." rows={4} /> <button type="submit" className="bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition" > Submit </button> </form> </main> ); }
Step 2: Define the @modal slot
The @modal slot ensures that no modal is rendered unless explicitly triggered.
Listing 5:
// app/@modal/default.tsx export default function Default() { return null; // Ensures the modal is not active by default }
Step 3: Implement the modal in the /(.)feedback folder
This step uses the intercepting route pattern (.) to render the modal in the @modal slot.
Listing 6:
// app/@modal/(.)feedback/page.tsx import { Modal } from '@/components/modal'; export default function FeedbackModal() { return ( <Modal> <h2 className="text-lg font-bold text-gray-800">Give Feedback</h2> <form className="mt-4 flex flex-col gap-4"> <textarea className="border border-gray-300 rounded-lg p-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Your feedback..." rows={4} /> <button type="submit" className="bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition" > Submit </button> </form> </Modal> ); }
Step 4: Create the reusable modal component
The modal is styled using TailwindCSS for a modern and accessible design.
Listing 7:
// components/modal.tsx 'use client'; import { useRouter } from 'next/navigation'; export function Modal({ children }: { children: React.ReactNode }) { const router = useRouter(); return ( <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> <div className="bg-white rounded-lg shadow-lg max-w-md w-full p-6 relative"> <button onClick={() => router.back()} aria-label="Close" className="absolute top-2 right-2 text-gray-400 hover:text-gray-600" > ✖ </button> {children} </div> </div> ); }
Step 5: Update the layout for parallel routing
In the layout, the @modal slot is rendered next to the primary children.
Listing 8:
// app/layout.tsx import Link from 'next/link'; import './globals.css'; export default function RootLayout({ modal, children, }: { modal: React.ReactNode; children: React.ReactNode; }) { return ( <html lang="en"> <body className="bg-gray-100 text-gray-900"> <nav className="bg-gray-800 p-4 text-white"> <Link href="/feedback" className="hover:underline text-white" > Give Feedback </Link> </nav> <div>{modal}</div> <main className="p-4">{children}</main> </body> </html> ); }
You can find the complete implementation using TailwindCSS, including accessibility enhancements, on my GitHub repository.
Advanced features and enhancements
Accessibility improvements
Accessibility is critical when creating modals. Without proper implementation, modals can confuse users, especially those who rely on screen readers or keyboard navigation. Here are some key practices to ensure that your modal is accessible:
Focus management
When a modal is opened, the focus should be moved to the first interactive element within the modal, and users should not be able to interact with elements outside the modal. In addition, when the modal is closed, the focus should return to the element that triggered it.
This can be achieved by using JavaScript to trap focus within the modal:
Listing 9:
// Updated Modal Component with Focus Management 'use client'; import { useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; export function Modal({ children }: { children: React.ReactNode }) { const router = useRouter(); const modalRef = useRef<HTMLDivElement>(null); useEffect(() => { const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, textarea, select, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements?.[0] as HTMLElement; const lastElement = focusableElements?.[focusableElements.length - 1] as HTMLElement; // Trap focus within the modal function handleTab(e: KeyboardEvent) { if (!focusableElements || focusableElements.length === 0) return; if (e.key === 'Tab') { if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement?.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement?.focus(); } } } // Set initial focus to the first interactive element firstElement?.focus(); window.addEventListener('keydown', handleTab); return () => window.removeEventListener('keydown', handleTab); }, []); return ( <div ref={modalRef} role="dialog" aria-modal="true" className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50" > <div className="bg-white rounded-lg shadow-lg max-w-md w-full p-6 relative"> <button onClick={() => router.back()} aria-label="Close" className="absolute top-2 right-2 text-gray-400 hover:text-gray-600" > ✖ </button> {children} </div> </div> ); }
Focus trapping is essential for maintaining a seamless and accessible user experience when working with modals. It ensures that users cannot accidentally navigate or interact with elements outside the modal while it is open, preventing confusion and unintended actions. Additionally, returning focus to the element that triggered the modal provides a smooth transition when the modal is closed, helping users reorient themselves and continue interacting with the application without disruption. These practices enhance both usability and accessibility, creating a more polished and user-friendly interface.
ARIA attributes
Using semantic HTML and ARIA attributes ensures that screen readers understand the structure and purpose of the modal.
- Add role=”dialog” to the modal container to define it as a dialog window.
- Use aria-modal=”true” to indicate that interaction with elements outside the modal is restricted.
Why this is important:
ARIA attributes provide assistive technologies such as screen readers with the necessary context to communicate the purpose of the modal to the user. This ensures a consistent and inclusive user experience.
Error handling and edge cases
Handling edge cases ensures that your modal behaves predictably in all scenarios. Here are some considerations:
Handle Refreshes
Since the modal state is tied to the URL, refreshing the page should display the appropriate content. In Next.js, this happens naturally due to the server-rendered /feedback route and the modal implementation.
Close modal on invalid routes
If the user navigates to an invalid route, the modal should close or render nothing. A catch-all route ([…catchAll]) in the @modal slot ensures this:
export default function CatchAll() { return null; // Ensures the modal slot is empty }
Smooth navigation
Ensure that navigating to another part of the application closes the modal. Using router.back() in the modal close button ensures that the user is returned to the previous route.
Listing 10:
<button onClick={() => router.back()} aria-label="Close" className="absolute top-2 right-2 text-gray-400 hover:text-gray-600" > ✖ </button>
Why it matters:
Graceful navigation plays a key role in providing a consistent and predictable user experience, even when users interact with modals in unexpected ways. By ensuring that modal behaviour aligns with navigation actions, such as using the back or forward buttons, users can move through the application naturally without encountering inconsistencies.
Catch-all routes further enhance robustness by preventing unnecessary or unintended content from being rendered in the modal slot. They act as a safeguard, ensuring that only valid routes display content, while invalid or undefined routes leave the modal slot empty. Together, these strategies create a more reliable and user-friendly application.
Comparison and use cases
Comparison: URL-synced modals vs. traditional client-side modals
When building modals, developers often rely on client-side state management to control their visibility. While this approach is straightforward, it has several limitations compared to URL-synced modals in Next.js:
Feature | Client-side modals | URL-synced modals in Next.js |
---|---|---|
Deep Linking | Not supported. Users can’t share or bookmark the modal state. | Fully supported. Modal states are linked to specific URLs. |
Refresh Behaviour | When the page is refreshed, the modal state is reset and closed. | The modal state persists across refreshes. |
Navigation Consistency | Backwards or forward navigation cannot close or reopen the modal. | Modals respect browser navigation, closing or reopening correctly. |
Scalability | State management for complex modals can be difficult in large applications. | Simplified state management using URL routes. |
SEO and Accessibility | Modals are not indexed or accessible via URLs. | Can be indexed and shared where appropriate. |
Why URL-synchronised modals are important:
These features significantly enhance the user experience by enabling deep linking, allowing users to share and bookmark specific modal states with ease. Navigation consistency ensures that actions like using the back or forward buttons behave as expected, seamlessly opening or closing modals without disrupting the flow of the application. For developers, Next.js simplifies state management by leveraging its routing mechanisms, eliminating the need for complex custom logic to control modal behaviour. This combination of improved usability and reduced development complexity makes Next.js an ideal framework for building modern, shareable modals.
Practical use cases for URL-synced modals
Next.js makes URL-synced modals versatile and scalable. Here are a few common use cases:
Feedback forms
As this article shows, feedback forms are ideal for modals. Users can easily share a link to the form (/feedback), and the form remains accessible even after a page refresh.
Photo galleries with previews
Imagine a gallery where users can click on a thumbnail to open a photo preview in a modal. With URL-synchronised modals:
- Clicking on a photo updates the URL (e.g. /gallery/photo/123).
- Users can share the link, allowing others to view the photo directly.
- Navigating backwards or forwards closes or reopens the modal.
Shopping Cart and Side Panels
E-commerce applications often use modals for shopping carts. With URL-synced modals:
- The cart can be linked to a route such as /cart.
- Users can share their cart link with preloaded items.
- Refreshing the page keeps the cart open, preventing it from losing its state.
Authentication and login
For applications that require authentication, login forms can be presented as modals. A user clicking “Login” could open a modal linked to “/login.” When the modal is closed or the user navigates elsewhere, the state remains predictable.
Notifications and Wizards
- Notifications: Display announcements or updates in a modal tied to a route, such as /announcement.
- Onboarding Wizards: Guide users through a multistep onboarding process, with each step linked to a unique URL (e.g. /onboarding/step-1).
When to avoid URL-synced modals
Although URL-synced modals are powerful, they are not appropriate for every scenario. Consider avoiding them in the following cases:
- Highly transient states: Modals used for brief interactions (such as confirming a delete action) may not require URL updates.
- Sensitive data: If the modal contains sensitive information, ensure that deep linking and sharing are restricted.
- Non-navigable workflows: If the modal does not require navigation controls (e.g. forward/backwards), simpler client-side modals may be sufficient.
With these comparisons and use cases, developers can make informed decisions about when and how to implement URL-synced modals in their Next.js projects.
Conclusion
URL-synchronised modals in Next.js provide a modern solution to the common challenges developers face when implementing modals in web applications. By leveraging features such as intercepting and parallel routes, Next.js enables deep linking, navigation consistency, and improved user experience – all while simplifying state management.
Key Takeaways
- Improved user experience:
URL-synchronised modals allow users to share, bookmark, and revisit specific modal states without breaking functionality. They also respect browser navigation, ensuring that modals open and close as expected. - Simplified state management:
By tying modal states to the URL, developers can avoid the complexity of managing client-side state for modals in large applications. - Broad applicability:
From feedback forms and photo galleries to shopping carts and onboarding wizards, URL-synced modals provide a scalable and reusable solution for multiple use cases.
Recommendations:
- Use Next.js’ intercepting and parallel routes to create modals that integrate seamlessly into your application.
- Focus on accessibility by implementing ARIA roles, focus trapping, and logical navigation.
- Evaluate whether URL-synced modals are appropriate for your specific use case, especially when dealing with transient or sensitive data.
For a complete example of building a feedback modal with URL-synced functionality in Next.js, check out my GitHub repository.
If you’re ready to take your Next.js projects to the next level, try implementing URL-synced modals today. They are not only user-friendly but also developer-friendly, making them a great addition to any modern web application.